AWSサービス毎の請求額を毎日LINEに通知してみた
(追記)本記事の内容を改良した、最新版のAWS料金通知ツールのブログ記事を公開しました!
こんにちは、つくぼし(tsukuboshi0755)です!
みなさんは、利用中の AWS 料金を逐一把握されていますでしょうか?
リソースの消し忘れ等で、いつのまにか AWS からの請求額がとんでもない事になっていた...という体験談を持つ方もいらっしゃるかと思います。(私もその一人です)
上記の対策として、以下の記事のように、AWS の請求額を毎日通知するシステムを構築し、確認する方法が挙げられます。
こちらのシステムは非常に便利なのですが、 Slack への通知が前提となるため、普段 Slack を利用していない方からすると多少扱いづらいかもしれません。
そこで今回は、上記のシステムを少しいじり、大半の方がプライベートで利用しているであろう LINE に対して、月初からの AWS サービス毎の請求額を毎日通知するシステムを構築してみます!
前提条件
今回は下記のソフトウェアの使用を前提としています。
足りない方は個別で導入/設定をご実施ください。
項目 | バージョン |
---|---|
AWS CLI | 2.4 |
AWS SAM CLI | 1.75 |
Python | 3.9 |
事前準備
以下で、対象システムを構築するために必要な準備を実施します。
Cost Explorer の有効化
もし Cost Explorer がまだ有効になっていない場合は、この段階で有効にしておきます。
ルートアカウントでログインし、設定画面から Cost Explorer を有効にします。
なおAWS公式にもCost Explorer の有効化手順の記載がありますので、ご参照ください。
LINE Notify の Access Token 発行
今回は LINE Notify で Access Token を発行し、こちらを用いて LINE に通知を行います。
初めにLINE Notify 公式ページにアクセスし、右上の"ログイン"を押して、自身の LINE アカウントにログインします。
ログイン後、右上のペインから"マイページ"をクリックします。
マイページから、"トークンの発行"ボタンを押します。
任意のトークン名を入力し、通知を送信するトークルームを選択します。
今回は"1:1からLINE Notifyから通知を受け取る"を選択していますが、特定の LINE グループを選択する事も可能です。
なお LINE グループに通知する場合は、LINE Notify アカウントを該当グループのメンバーとして追加する必要があるためご注意ください。
トークン名の入力とトークルームの選択が完了したら、"発行する"ボタンを押してください。
"コピー"ボタンを押して、発行された Access Token をコピーします。
こちらは SAM アプリデプロイ時に使用するため、忘れずにメモしておいてください。
システム構築
以下で、対象システムの構築の流れについて説明します。
なお通知先が Slack ではなく LINE である事以外は、AWSサービス毎の請求額を毎日Slackに通知してみたブログとシステム構成はほぼ同じです。
SAM プロジェクトの作成
AWS SAM CLI でプロジェクトを作成します。
なお今回は SAM 初期化コマンドに対して事前にオプションを指定し、一発でプロジェクトが作成されるようにします。
sam init \ --runtime python3.9 \ --app-template hello-world \ --name notify-line-to-aws-billing \ --no-application-insights \ --no-tracing
作成後、プロジェクトディレクトリに移動します。
cd notify-line-to-aws-billing
なお SAM 初期化後のプロジェクトディレクトリの中身は、下記の通りです。
$ tree . ├── README.md ├── __init__.py ├── events │ └── event.json ├── hello_world │ ├── __init__.py │ ├── app.py │ └── requirements.txt ├── samconfig.toml ├── template.yaml └── tests ├── __init__.py ├── integration │ ├── __init__.py │ └── test_api_gateway.py ├── requirements.txt └── unit ├── __init__.py └── test_handler.py 6 directories, 14 files
SAM テンプレートファイルの変更
プロジェクトフォルダ内のtemplate.yaml
を、下記の内容に変更します。
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Notify Line every day of AWS billing Globals: Function: Timeout: 10 Parameters: LineAccessToken: Type: String Default: hoge Resources: # https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html # https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-lambda-state-machine-cloudformation.html BillingIamRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: "sts:AssumeRole" Policies: - PolicyName: "NotifyLineToBillingLambdaPolicy" PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - "logs:CreateLogGroup" - "logs:CreateLogStream" - "logs:PutLogEvents" - "ce:GetCostAndUsage" Resource: "*" HelloWorldFunction: Type: AWS::Serverless::Function Properties: CodeUri: hello_world/ Handler: app.lambda_handler Runtime: python3.9 Environment: Variables: # このURLはコミット&公開したくないため、デプロイ時にコマンドで設定する LINE_ACCESS_TOKEN: !Ref LineAccessToken Role: !GetAtt BillingIamRole.Arn Events: NotifyLine: Type: Schedule Properties: Schedule: cron(0 0 * * ? *) # 日本時間AM9時に毎日通知する Outputs: HelloWorldFunction: Description: "Hello World Lambda Function ARN" Value: !GetAtt HelloWorldFunction.Arn HelloWorldFunctionIamRole: Description: "Implicit IAM Role created for Hello World function" Value: !GetAtt BillingIamRole.Arn
Lambda 関数コードの変更
プロジェクトフォルダ内のhello_world/app.py
を、下記の内容に変更します。
import os import boto3 import requests from datetime import datetime, timedelta, date LINE_ACCESS_TOKEN = os.environ["LINE_ACCESS_TOKEN"] def lambda_handler(event, context) -> None: client = boto3.client('ce', region_name='us-east-1') # 合計とサービス毎の請求額を取得する total_billing = get_total_billing(client) service_billings = get_service_billings(client) # Line用のメッセージを作成して投げる (title, detail) = get_message(total_billing, service_billings) post_line(title, detail) def get_total_billing(client) -> dict: (start_date, end_date) = get_total_cost_date_range() # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ce.html#CostExplorer.Client.get_cost_and_usage response = client.get_cost_and_usage( TimePeriod={ 'Start': start_date, 'End': end_date }, Granularity='MONTHLY', Metrics=[ 'AmortizedCost' ] ) return { 'start': response['ResultsByTime'][0]['TimePeriod']['Start'], 'end': response['ResultsByTime'][0]['TimePeriod']['End'], 'billing': response['ResultsByTime'][0]['Total']['AmortizedCost']['Amount'], } def get_service_billings(client) -> list: (start_date, end_date) = get_total_cost_date_range() # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ce.html#CostExplorer.Client.get_cost_and_usage response = client.get_cost_and_usage( TimePeriod={ 'Start': start_date, 'End': end_date }, Granularity='MONTHLY', Metrics=[ 'AmortizedCost' ], GroupBy=[ { 'Type': 'DIMENSION', 'Key': 'SERVICE' } ] ) billings = [] for item in response['ResultsByTime'][0]['Groups']: billings.append({ 'service_name': item['Keys'][0], 'billing': item['Metrics']['AmortizedCost']['Amount'] }) return billings def get_message(total_billing: dict, service_billings: list) -> (str, str): start = datetime.strptime(total_billing['start'], '%Y-%m-%d').strftime('%m/%d') # Endの日付は結果に含まないため、表示上は前日にしておく end_today = datetime.strptime(total_billing['end'], '%Y-%m-%d') end_yesterday = (end_today - timedelta(days=1)).strftime('%m/%d') total = round(float(total_billing['billing']), 2) title = f'{start}~{end_yesterday}の請求額は、{total:.2f} USDです。' details = [] for item in service_billings: service_name = item['service_name'] billing = round(float(item['billing']), 2) if billing == 0.0: # 請求無し(0.0 USD)の場合は、内訳を表示しない continue details.append(f' ・{service_name}: {billing:.2f} USD') return title, '\n'.join(details) def post_line(title: str, detail: str) -> None: # https://notify-bot.line.me/doc/ja/ url = "https://notify-api.line.me/api/notify" headers = {"Authorization": "Bearer %s" % LINE_ACCESS_TOKEN} data = {'message': f'{title}\n\n{detail}'} try: response = requests.post(url, headers=headers, data=data) except requests.exceptions.RequestException as e: print(e) else: print(response.status_code) def get_total_cost_date_range() -> (str, str): start_date = get_begin_of_month() end_date = get_today() # get_cost_and_usage()のstartとendに同じ日付は指定不可のため、 # 「今日が1日」なら、「先月1日から今月1日(今日)」までの範囲にする if start_date == end_date: end_of_month = datetime.strptime(start_date, '%Y-%m-%d') + timedelta(days=-1) begin_of_month = end_of_month.replace(day=1) return begin_of_month.date().isoformat(), end_date return start_date, end_date def get_begin_of_month() -> str: return date.today().replace(day=1).isoformat() def get_prev_day(prev: int) -> str: return (date.today() - timedelta(days=prev)).isoformat() def get_today() -> str: return date.today().isoformat()
S3 バケットの作成
コード等を格納するための S3 バケットを事前に作成します。(バケット名は任意の名前でOKです)
aws s3 mb s3://cm-tsukuboshi-lambda-bucket
SAM アプリのビルド/パッケージング/デプロイ
初めにSAM プロジェクトのルートディレクトリにて、下記コマンドで SAM アプリをビルドします。
なお、もしビルドがローカルの依存関係で失敗してしまう場合は、--use-container
オプションの追加を検討してみてください。
(別途 Docker の導入が必要になります)
sam build
続いて下記コマンドでコード一式を S3 バケットにアップロードし、 SAM アプリをパッケージングします。
sam package \ --output-template-file packaged.yaml \ --s3-bucket cm-tsukuboshi-lambda-bucket
最後に下記コマンドで SAM アプリをデプロイします。
template.yaml
の環境変数を--parameter-overrides
オプションで上書きし、ここで事前準備でメモした LINE Notify の Access Token を設定します。
sam deploy \ --template-file packaged.yaml \ --stack-name NotifyBillingToLine \ --capabilities CAPABILITY_IAM \ --parameter-overrides LineAccessToken=xxxxxxxxxxxx
Lambda 関数のテスト
デプロイ後の Lambda 関数がきちんと動くかテストします。
Lambda コンソールにアクセスし、対象関数を選択した後、テストタブを選択し"テスト"ボタンを押します。
通知先として設定した LINE Notify アカウント(または LINE グループ内)から、以下のような AWS 料金に関するメッセージが届けば、 Lambda の動作としてはOKです!
あとは指定時刻(今回の場合はAM9:00)まで待機し、同じようなメッセージが届けば EventBridge の動作も問題ありません!
本システムでかかる AWS 料金
(2023/3/6) 本システムの利用時にかかる AWS 料金の詳細を追記しました。
以下では、本システムの利用時にかかる AWS 料金について説明します。
Lambda については、現時点で大量の Lambda リクエストを実施していない限り、下記の Lambda の無料利用枠に収まります。
(私も本システムを利用していますが、無料利用枠内に収まっています)
AWS Lambda の無料利用枠には、1 か月あたり 100 万件の無料リクエストと、1 か月あたり 40 万 GB-s のコンピューティングタイムが含まれており、x86 および Graviton2 プロセッサの両方を搭載した機能を利用できます。
一方で Lambda 関数で呼び出している Cost Explorer API の料金として、1回のリクエストにつき0.01USD発生します。
今回の Lambda 関数を1回実行すると、"合計料金"と"サービス別料金"を取得するために2回APIリクエストを投げる事になるため、0.02USDかかる計算になります。
ページ分割された API リクエストごとに 0.01 USD の料金が発生します
加えて、上記に10%の税金が付与されます。
そのため、毎日こちらのシステムを起動した場合、月額換算(1ヶ月30日)で以下の料金になる事が予測されます。
(0.02USD * 30回) * 1.1 = 0.66USD(≒約90円)
なお上記の料金は、 Lambda 関数のテスト実行数や月の日数、作成したコード保管用バケット等の要因で多少変動します。
そのため、あくまで目安として頂けるとありがたいです!
最後に
今回は、月初からの請求額を毎日 LINE に通知するシステムを構築してみました。
普段から AWS を利用する方は、 AWS の想定外の高額利用を防ぐために、ぜひ構築を検討してみてください。
以上、つくぼし(tsukuboshi0755)でした!